feat: WebSocket transport β full-duplex resumable streams#969
feat: WebSocket transport β full-duplex resumable streams#969AlemTuzlak wants to merge 24 commits into
Conversation
Add a full-duplex WebSocket chat demo to ts-react-chat: a Vite dev-server
plugin (websocket-chat-plugin.ts) hooks the Node http server's `upgrade`
event and wires toWebSocketStream/resumeWebSocketStream around chat() with
gpt-5.5, mirroring the pattern in testing/e2e's durable-delivery-ws-plugin
(no WebSocketPair on Node/Nitro). The /websocket-chat route uses useChat with
the webSocket() connection adapter, linked from the nav.
@tanstack/ai-react didn't re-export webSocket/WebSocketConnectionOptions from
@tanstack/ai-client yet (only the other connection adapters were), so this
adds that re-export as it's required for the example's `import { useChat,
webSocket } from '@tanstack/ai-react'`.
β¦e/svelte/angular
Document the new WebSocket transport (toWebSocketStream/toWebSocketResponse, resumeWebSocketStream/resumeWebSocketResponse, client webSocket()): a new standalone WebSockets page, a short intro + snippet in the Overview, a reconnect/lifecycle summary in Advanced, and a pointer from Connection Adapters to the built-in adapter instead of the old hand-rolled example.
β¦/heartbeat; remove abort listener leak
π Changeset Version Preview8 package(s) bumped directly, 39 bumped as dependents. π₯ Major bumps
π¨ Minor bumps
π© Patch bumps
|
|
View your CI Pipeline Execution β for commit 2225a78
βοΈ Nx Cloud last updated this comment at |
π WalkthroughWalkthroughA new resumable WebSocket transport adds server streaming and replay helpers, a client adapter with reconnect tracking, framework re-exports, React and end-to-end examples, comprehensive tests, and documentation covering protocol, lifecycle, hosting, and durability. ChangesWebSocket transport
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant useChat
participant webSocket
participant toWebSocketStream
participant chat
participant memoryStream
useChat->>webSocket: send RunAgentInput
webSocket->>toWebSocketStream: send run frame
toWebSocketStream->>chat: invoke onRun with turn context
chat->>memoryStream: persist durable chunks
toWebSocketStream-->>webSocket: send chunk envelopes
webSocket-->>useChat: yield streamed chunks
webSocket->>toWebSocketStream: reconnect with runId and offset
toWebSocketStream->>memoryStream: replay missing chunks
memoryStream-->>useChat: deliver resumed chunks
Possibly related PRs
Suggested reviewers: π₯ Pre-merge checks | β 4 | β 1β Failed checks (1 warning)
β Passed checks (4 passed)
β¨ Finishing Touchesπ Generate docstrings
π§ͺ Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. π§ ESLint
ESLint install timed out. The project may have too many dependencies for the sandbox. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-durable-stream
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
There was a problem hiding this comment.
Actionable comments posted: 5
π§Ή Nitpick comments (2)
examples/ts-react-chat/src/lib/websocket-chat-plugin.ts (1)
40-46: π Maintainability & Code Quality | π΅ Trivial | π€ Low valuePropagate the abort reason to the new controller.
When bridging an
AbortSignalto a newAbortController, passingsignal.reasonensures that any downstream cancellation logs or error handlers receive the correct context rather than a generic abort error.
examples/ts-react-chat/src/lib/websocket-chat-plugin.ts#L40-L46: passsignal.reasontocontroller.abort()in the plugin.docs/resumable-streams/websockets.md#L56-L61: passsignal.reasontocontroller.abort()in the documentation sample.π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@examples/ts-react-chat/src/lib/websocket-chat-plugin.ts` around lines 40 - 46, Propagate the original abort reason when bridging signals: update abortControllerFromSignal in examples/ts-react-chat/src/lib/websocket-chat-plugin.ts lines 40-46 and the corresponding abort listener in docs/resumable-streams/websockets.md lines 56-61 to pass signal.reason to controller.abort(), preserving the existing immediate and event-driven cancellation behavior.packages/ai/src/stream-to-websocket.ts (1)
204-211: π Performance & Scalability | π΅ Trivial | βοΈ Poor tradeoffNo send backpressure β
bufferedAmountis declared but never consulted.Both pump loops call
socket.send(...)on every chunk without awaiting drain. A fastonRun/durable replay against a slow client will queue frames unbounded in the socket's send buffer, growing memory. TheWebSocketLike.bufferedAmountfield (Line 20) appears intended for this but is unused; the resume pump at Line 265-266 has the same gap. Consider pausing iteration whilebufferedAmountexceeds a high-water mark.π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai/src/stream-to-websocket.ts` around lines 204 - 211, Update both stream pump loops around source and init.onRun, plus the resume pump, to apply send backpressure before calling socket.send. Pause iteration while WebSocketLike.bufferedAmount exceeds a defined high-water threshold, then resume once the buffer drains, preserving chunk order and existing frame encoding.
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/chat/connection-adapters.md`:
- Around line 299-312: Add a concise server endpoint example to the WebSockets
section alongside the existing client webSocket usage, demonstrating the paired
toWebSocketStream or toWebSocketResponse integration. Keep the example
consistent with the documented server-side WebSocket APIs and retain the
existing link for protocol and hosting details.
In `@docs/resumable-streams/websockets.md`:
- Line 243: Remove the `as unknown as WebSocketLike` assertion from the
`socketLike` assignment in the WebSocket example; pass `ws` directly if it
satisfies the interface, otherwise use a type guard or property validation so
the sample type-checks without any `as` cast.
In `@packages/ai-client/src/connection-adapters.ts`:
- Around line 1690-1691: Update the ws.onmessage handler to guard
JSON.parse(String(event.data)) with try/catch. On malformed frames, call failAll
with a StreamReadError containing the caught error so stream consumers receive a
deterministic failure instead of hanging; preserve the existing handling for
successfully parsed messages.
- Around line 1721-1734: Update the ws.onclose handler in the connection adapter
to notify joinRun listeners when the socket closes even if currentSession is
undefined. Preserve the existing session reconnect and non-durable failure
behavior, while ensuring pending joinRun iterators are rejected or otherwise
terminated instead of remaining unresolved.
In `@packages/ai/src/stream-to-websocket.ts`:
- Around line 179-221: The handleInbound function must not overwrite an active
controller for the same runId. Before activeTurns.set(params.runId, turnAbort),
retrieve any existing controller, abort it or reject the duplicate run, then
store the new controller only when appropriate; preserve the existing ownership
check in the finally cleanup.
---
Nitpick comments:
In `@examples/ts-react-chat/src/lib/websocket-chat-plugin.ts`:
- Around line 40-46: Propagate the original abort reason when bridging signals:
update abortControllerFromSignal in
examples/ts-react-chat/src/lib/websocket-chat-plugin.ts lines 40-46 and the
corresponding abort listener in docs/resumable-streams/websockets.md lines 56-61
to pass signal.reason to controller.abort(), preserving the existing immediate
and event-driven cancellation behavior.
In `@packages/ai/src/stream-to-websocket.ts`:
- Around line 204-211: Update both stream pump loops around source and
init.onRun, plus the resume pump, to apply send backpressure before calling
socket.send. Pause iteration while WebSocketLike.bufferedAmount exceeds a
defined high-water threshold, then resume once the buffer drains, preserving
chunk order and existing frame encoding.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9e15cc23-dd08-43f6-a7e2-eebf70c11658
β Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
π Files selected for processing (30)
.changeset/websocket-transport.mddocs/chat/connection-adapters.mddocs/config.jsondocs/resumable-streams/advanced.mddocs/resumable-streams/overview.mddocs/resumable-streams/websockets.mdexamples/ts-react-chat/package.jsonexamples/ts-react-chat/src/components/Header.tsxexamples/ts-react-chat/src/lib/websocket-chat-plugin.tsexamples/ts-react-chat/src/routeTree.gen.tsexamples/ts-react-chat/src/routes/websocket-chat.tsxexamples/ts-react-chat/vite.config.tspackages/ai-angular/src/index.tspackages/ai-client/src/connection-adapters.tspackages/ai-client/src/index.tspackages/ai-client/tests/connection-adapters-resumable.test.tspackages/ai-client/tests/connection-adapters-websocket.test.tspackages/ai-react/src/index.tspackages/ai-solid/src/index.tspackages/ai-svelte/src/index.tspackages/ai-vue/src/index.tspackages/ai/src/index.tspackages/ai/src/stream-to-response.tspackages/ai/src/stream-to-websocket.tspackages/ai/tests/stream-to-websocket.test.tstesting/e2e/package.jsontesting/e2e/src/lib/durable-delivery-ws-plugin.tstesting/e2e/src/routes/api.durable-delivery.tstesting/e2e/tests/websocket.spec.tstesting/e2e/vite.config.ts
| ## WebSockets | ||
|
|
||
| For a persistent, resumable WebSocket, use the built-in `webSocket()` adapter instead of hand-rolling a `SubscribeConnectionAdapter`. It opens one socket for the whole conversation, reconnects a dropped durable run automatically, and pairs with the server's `toWebSocketStream` / `toWebSocketResponse`: | ||
|
|
||
| ```typescript | ||
| import { useChat, webSocket } from "@tanstack/ai-react"; | ||
|
|
||
| const connection = webSocket("/api/chat-ws"); | ||
|
|
||
| const { messages, sendMessage } = useChat({ connection }); | ||
| ``` | ||
|
|
||
| See [WebSockets](../resumable-streams/websockets) for the server side, the wire protocol, reconnect details, and hosting on Node vs Cloudflare. | ||
|
|
There was a problem hiding this comment.
π Maintainability & Code Quality | π‘ Minor | β‘ Quick win
Add a server endpoint snippet for WebSockets.
As per coding guidelines, when a documentation page covers both server and client behavior, snippets for both halves (the server endpoint and the client consumption) must be included. The SSE section on this page includes a server snippet, but the new WebSockets section only provides the client webSocket snippet. Please add a brief example of the paired server endpoint (e.g., using toWebSocketStream or toWebSocketResponse).
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/chat/connection-adapters.md` around lines 299 - 312, Add a concise
server endpoint example to the WebSockets section alongside the existing client
webSocket usage, demonstrating the paired toWebSocketStream or
toWebSocketResponse integration. Keep the example consistent with the documented
server-side WebSocket APIs and retain the existing link for protocol and hosting
details.
Source: Coding guidelines
|
|
||
| wss.handleUpgrade(req, socket, head, (ws) => { | ||
| const request = new Request(url) | ||
| const socketLike = ws as unknown as WebSocketLike |
There was a problem hiding this comment.
π Maintainability & Code Quality | π‘ Minor | β‘ Quick win
Remove the as type-assertion cast.
As per coding guidelines, do not use as type-assertion casts in documentation code samples. Examples must type-check without as SomeType; consider using a type guard or validating properties if the types do not natively align, or simply passing ws directly if it structurally satisfies the interface.
π Proposed fix
- const socketLike = ws as unknown as WebSocketLike
-
if (url.searchParams.get('offset') !== null) {
- resumeWebSocketStream(socketLike, { adapter: memoryStream(request) })
+ resumeWebSocketStream(ws, { adapter: memoryStream(request) })
} else {
- handleChatSocket(socketLike, request)
+ handleChatSocket(ws, request)
}π Committable suggestion
βΌοΈ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const socketLike = ws as unknown as WebSocketLike | |
| if (url.searchParams.get('offset') !== null) { | |
| resumeWebSocketStream(ws, { adapter: memoryStream(request) }) | |
| } else { | |
| handleChatSocket(ws, request) | |
| } |
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/resumable-streams/websockets.md` at line 243, Remove the `as unknown as
WebSocketLike` assertion from the `socketLike` assignment in the WebSocket
example; pass `ws` directly if it satisfies the interface, otherwise use a type
guard or property validation so the sample type-checks without any `as` cast.
Source: Coding guidelines
| ws.onmessage = (event: MessageEvent) => { | ||
| const parsed: unknown = JSON.parse(String(event.data)) |
There was a problem hiding this comment.
π©Ί Stability & Availability | π‘ Minor | β‘ Quick win
π§© Analysis chain
π Script executed:
#!/bin/bash
set -euo pipefail
file="packages/ai-client/src/connection-adapters.ts"
echo "== outline =="
ast-grep outline "$file" --view expanded || true
echo
echo "== relevant slice around lines 1660-1715 =="
sed -n '1660,1715p' "$file" | cat -n
echo
echo "== search for failAll and StreamReadError in file =="
rg -n "failAll|StreamReadError|onmessage|JSON\.parse" "$file"Repository: TanStack/ai
Length of output: 8433
Guard malformed WebSocket frames before parsing
ws.onmessage parses event.data unguarded, so a bad frame throws out of the handler and leaves the stream without a deterministic failure path. Wrap this in try/catch and call failAll(new StreamReadError(error)) (or drop the frame explicitly) so consumers donβt hang on a corrupted message.
Suggested guard
ws.onmessage = (event: MessageEvent) => {
- const parsed: unknown = JSON.parse(String(event.data))
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(String(event.data))
+ } catch (error) {
+ failAll(new StreamReadError(error))
+ return
+ }π Committable suggestion
βΌοΈ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ws.onmessage = (event: MessageEvent) => { | |
| const parsed: unknown = JSON.parse(String(event.data)) | |
| ws.onmessage = (event: MessageEvent) => { | |
| let parsed: unknown | |
| try { | |
| parsed = JSON.parse(String(event.data)) | |
| } catch (error) { | |
| failAll(new StreamReadError(error)) | |
| return | |
| } |
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-client/src/connection-adapters.ts` around lines 1690 - 1691,
Update the ws.onmessage handler to guard JSON.parse(String(event.data)) with
try/catch. On malformed frames, call failAll with a StreamReadError containing
the caught error so stream consumers receive a deterministic failure instead of
hanging; preserve the existing handling for successfully parsed messages.
| ws.onclose = () => { | ||
| const session = currentSession | ||
| if (!session || session.signal?.aborted || session.sawTerminal) return | ||
| const lastEventId = session.tracker.lastEventId | ||
| if (lastEventId === undefined) { | ||
| // Non-durable run (no offset ever observed) β nothing to resume | ||
| // from. Surface a hard failure rather than silently reconnecting | ||
| // forever against a server that never tags its events. | ||
| currentSession = undefined | ||
| failAll(new StreamReadError(new Error('WebSocket connection closed'))) | ||
| return | ||
| } | ||
| void reconnect(session, lastEventId) | ||
| } |
There was a problem hiding this comment.
π©Ί Stability & Availability | π‘ Minor | β‘ Quick win
π§© Analysis chain
π Script executed:
#!/bin/bash
set -euo pipefail
# Map the file structure first
ast-grep outline packages/ai-client/src/connection-adapters.ts --view expanded || true
# Inspect the relevant region around the cited lines
sed -n '1600,1785p' packages/ai-client/src/connection-adapters.ts
# Find joinRun / send / subscribe / onclose usage
rg -n "joinRun|subscribe|send\(|onclose|currentSession|sawTerminal|lastEventId|reconnect" packages/ai-client/src/connection-adapters.tsRepository: TanStack/ai
Length of output: 19758
π Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the runtime contract around the joinRun API and session lifecycle
sed -n '1,260p' packages/ai-client/src/connection-adapters.ts
# Look for tests or callers that clarify intended close behavior
rg -n "joinRun\\(|WebSocket connection closed|failAll\\(|abortSignal|StreamReadError|currentSession = undefined" packages -g '!**/dist/**'Repository: TanStack/ai
Length of output: 34747
π Script executed:
#!/bin/bash
set -euo pipefail
# Read the specific implementation in smaller chunks
cat -n packages/ai-client/src/connection-adapters.ts | sed -n '1,260p'Repository: TanStack/ai
Length of output: 11228
π Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the subscribe() iterator and joinRun() implementation in the WebSocket adapter
sed -n '1770,1915p' packages/ai-client/src/connection-adapters.ts
# Inspect websocket tests around joinRun and close behavior
sed -n '1,260p' packages/ai-client/tests/connection-adapters-websocket.test.ts
sed -n '260,420p' packages/ai-client/tests/connection-adapters-websocket.test.tsRepository: TanStack/ai
Length of output: 20365
π Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
text = Path("packages/ai-client/src/connection-adapters.ts").read_text()
start = text.index(" subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {")
end = text.index("export function stream(", start)
print(text[start:end])
PYRepository: TanStack/ai
Length of output: 39994
Fail joinRun() listeners on socket close
ws.onclose returns early when currentSession is undefined, so a joinRun()-only socket drop can leave the iterator parked on its pending promise forever. Surface the close to join-run listeners too, even without an active send/session.
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-client/src/connection-adapters.ts` around lines 1721 - 1734,
Update the ws.onclose handler in the connection adapter to notify joinRun
listeners when the socket closes even if currentSession is undefined. Preserve
the existing session reconnect and non-durable failure behavior, while ensuring
pending joinRun iterators are rejected or otherwise terminated instead of
remaining unresolved.
| async function handleInbound(input: unknown): Promise<void> { | ||
| const params = await chatParamsFromRequestBody(input) | ||
| const turnAbort = new AbortController() | ||
| activeTurns.set(params.runId, turnAbort) | ||
| const ctx: WsRunContext = { | ||
| messages: params.messages, | ||
| threadId: params.threadId, | ||
| runId: params.runId, | ||
| forwardedProps: params.forwardedProps, | ||
| resumeOffset: null, | ||
| request: buildTurnRequest(request, params.runId, null), | ||
| signal: turnAbort.signal, | ||
| } | ||
| try { | ||
| if (init.durability) { | ||
| const adapter = init.durability(ctx) | ||
| const { source, getId } = durableStreamSource( | ||
| init.onRun(ctx), | ||
| adapter, | ||
| { | ||
| abortController: turnAbort, | ||
| ...(init.batch === undefined ? {} : { batch: init.batch }), | ||
| logger, | ||
| }, | ||
| ) | ||
| for await (const chunk of source) { | ||
| socket.send(encodeWsFrame(chunk, getId(chunk))) | ||
| } | ||
| } else { | ||
| for await (const chunk of init.onRun(ctx)) { | ||
| socket.send(encodeWsFrame(chunk, undefined)) | ||
| } | ||
| } | ||
| } finally { | ||
| // Only delete if this turn still owns the entry: a duplicate in-flight | ||
| // runId (e.g. a client resubmitting before the first turn finished) | ||
| // would otherwise let the OLDER turn's cleanup delete the NEWER turn's | ||
| // still-active controller (TOCTOU). | ||
| if (activeTurns.get(params.runId) === turnAbort) { | ||
| activeTurns.delete(params.runId) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
π©Ί Stability & Availability | π‘ Minor | β‘ Quick win
π§© Analysis chain
π Script executed:
set -euo pipefail
# Map the relevant file structure and locate the symbols.
git ls-files packages/ai/src | sed -n '1,200p'
printf '\n--- outline ---\n'
ast-grep outline packages/ai/src/stream-to-websocket.ts --view expanded || true
printf '\n--- matches ---\n'
rg -n "activeTurns|runId|close handler|AbortController|handleInbound|onRun|durability" packages/ai/src/stream-to-websocket.ts packages/ai/src -SRepository: TanStack/ai
Length of output: 28059
π Script executed:
set -euo pipefail
# Read the relevant sections with line numbers.
sed -n '1,320p' packages/ai/src/stream-to-websocket.ts | cat -nRepository: TanStack/ai
Length of output: 13582
π Script executed:
set -euo pipefail
# Focused read of the file sections likely relevant to the duplicate-runId concern.
sed -n '120,260p' packages/ai/src/stream-to-websocket.ts | cat -nRepository: TanStack/ai
Length of output: 5819
Do not overwrite an in-flight runId. A second run frame with the same runId replaces the existing AbortController in activeTurns, so the earlier turn becomes unreachable. Both the close handler and the abort control frame only act on the map entry, which means the orphaned turn can keep running until it finishes on its own. Abort or reject any existing controller before storing the new one.
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai/src/stream-to-websocket.ts` around lines 179 - 221, The
handleInbound function must not overwrite an active controller for the same
runId. Before activeTurns.set(params.runId, turnAbort), retrieve any existing
controller, abort it or reject the duplicate run, then store the new controller
only when appropriate; preserve the existing ownership check in the finally
cleanup.
WebSocket transport
Adds WebSockets as a third streaming transport alongside SSE and NDJSON, reusing the delivery-durability seam that landed in #955. Full-duplex, conversation-scoped, resumable.
Server (
@tanstack/ai)toWebSocketStream(socket, request, { onRun, durability?, batch?, heartbeatMs?, idleTimeoutMs?, debug? })β portable core that pumps a conversation over an already-accepted WHATWGWebSocketLikeserver socket (Node viaws, Bun, etc.).toWebSocketResponse(request, init)β thin wrapper that upgrades viaWebSocketPairand returns a 101Responseon Cloudflare Workers/Durable Objects; throws elsewhere, pointing you totoWebSocketStream.resumeWebSocketStream(socket, { adapter })/resumeWebSocketResponse({ adapter })β read-only replay of a run from the durability log (no model call).chat()turns (client-tool resubmits, follow-up messages), you pass anonRun(ctx) => AsyncIterable<StreamChunk>factory instead of a prebuilt stream β it's called per inboundRunAgentInputframe. Durability is keyed per turn and reuses the existingdurableStreamSource(now exported), so serverβclient frames carry the same{ id, chunk }envelope as NDJSON.{ type: 'abort', runId }frame (aborts only that turn), or idle timeout; periodic{ type: 'ping' }heartbeat.Client (
@tanstack/ai-client, re-exported fromai-react/-solid/-vue/-svelte/-angular)webSocket(url, options)β full-duplexsubscribe+sendconnection adapter foruseChat.send()writes aRunAgentInputframe;subscribe()yields inbound chunks, ignores heartbeats, unwraps durable envelopes, and auto-reconnects a dropped durable run by reopening with?runId=&offset=(browsers can't set aLast-Event-IDhandshake header, so the offset rides in the URL).StreamReconnectLimitError) is shared with the HTTP adapters via the newcreateReconnectTracker. A fatal drop surfaces to the consumer instead of hanging.Testing
@tanstack/aistream-to-websocket 18/18;@tanstack/ai-clientconnection-adapter suites 63/63 (incl. reconnect, fatal-drop-surfacing, and open-promise-race regressions).upgradehook +ws) β ordered stream + reconnect-resume, 2/2; SSE/NDJSON regression 6/6./websocket-chatroute inexamples/ts-react-chat(type-checks + builds).docs/resumable-streams/overview + advanced + new WebSockets page; kiira-typechecked snippets, both server and client halves.Notes / follow-ups (v2, documented)
stop()does not emit an{ type: 'abort' }frame today β per-turn abort is via socket close (aborts all turns). The protocol primitive exists server-side.webSocket()connection assumes a single in-flight run at a time.π€ Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests